agentmux_srv\backend\storage/
content.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent-content blobs — per-agent key/value content storage (e.g.
5//! the "instructions" panel content).
6//!
7//! Extracted from `store.rs` in Phase R.4 of the storage
8//! modularization plan
9//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). The
10//! method surface is unchanged — `Store::agent_content_*` still
11//! lives on `Store` via this `impl` block; callers stay on
12//! `storage::store::AgentContent` thanks to the re-export.
13
14use rusqlite::params;
15use serde::{Deserialize, Serialize};
16
17use super::error::StoreError;
18use super::store::Store;
19
20/// A content blob attached to a agent definition (e.g. "instructions").
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct AgentContent {
23    pub agent_id: String,
24    pub content_type: String,
25    pub content: String,
26    pub updated_at: i64,
27}
28
29impl Store {
30    pub fn agent_content_get(
31        &self,
32        agent_id: &str,
33        content_type: &str,
34    ) -> Result<Option<AgentContent>, StoreError> {
35        let conn = self.conn.lock().unwrap();
36        let mut stmt = conn.prepare(
37            "SELECT agent_id, content_type, content, updated_at
38             FROM db_agent_content WHERE agent_id=?1 AND content_type=?2",
39        )?;
40        let local = {
41            let result = stmt.query_row(params![agent_id, content_type], |row| {
42                Ok(AgentContent {
43                    agent_id: row.get(0)?,
44                    content_type: row.get(1)?,
45                    content: row.get(2)?,
46                    updated_at: row.get(3)?,
47                })
48            });
49            match result {
50                Ok(content) => Some(content),
51                Err(rusqlite::Error::QueryReturnedNoRows) => None,
52                Err(e) => return Err(StoreError::Sqlite(e)),
53            }
54        };
55        drop(stmt);
56        drop(conn);
57        if local.is_some() {
58            return Ok(local);
59        }
60        // Cross-channel fallback (same gate as agent_content_get_all): only for
61        // an agent absent from local SQLite. Launch reads `env` via this single
62        // path, so without it a cross-channel agent would launch missing its
63        // env vars. (codex P2 on #1385.)
64        if self.agent_def_exists_local(agent_id)? {
65            return Ok(local);
66        }
67        if let Some(reg) = self.shared_def_registry() {
68            if let Ok(Some(rec)) = reg.get(agent_id) {
69                if let Some(c) = rec
70                    .data
71                    .content
72                    .iter()
73                    .find(|c| c.content_type == content_type)
74                {
75                    return Ok(Some(AgentContent {
76                        agent_id: agent_id.to_string(),
77                        content_type: c.content_type.clone(),
78                        content: c.content.clone(),
79                        updated_at: rec.data.updated_at,
80                    }));
81                }
82            }
83        }
84        Ok(local)
85    }
86
87    /// Upsert a content blob for an agent.
88    pub fn agent_content_set(&self, content: &AgentContent) -> Result<(), StoreError> {
89        {
90            let conn = self.conn.lock().unwrap();
91            conn.execute(
92                "INSERT INTO db_agent_content (agent_id, content_type, content, updated_at)
93                 VALUES (?1, ?2, ?3, ?4)
94                 ON CONFLICT(agent_id, content_type) DO UPDATE SET content=?3, updated_at=?4",
95                params![
96                    content.agent_id,
97                    content.content_type,
98                    content.content,
99                    content.updated_at,
100                ],
101            )?;
102        }
103        // conn dropped — re-mirror the definition so the global cross-channel
104        // record carries the updated content (no-op for seeded templates).
105        // (P0.2b.)
106        self.registry_def_upsert(&content.agent_id);
107        Ok(())
108    }
109
110    /// Get all content blobs for an agent.
111    /// LOCAL channel's content blobs only — NO cross-channel fallback. Used
112    /// by the def-registry mirror (which always operates on a local agent, so
113    /// must never read the global record) and by `agent_content_get_all`.
114    pub(super) fn agent_content_get_all_local(
115        &self,
116        agent_id: &str,
117    ) -> Result<Vec<AgentContent>, StoreError> {
118        let conn = self.conn.lock().unwrap();
119        let mut stmt = conn.prepare(
120            "SELECT agent_id, content_type, content, updated_at
121             FROM db_agent_content WHERE agent_id=?1 ORDER BY content_type ASC",
122        )?;
123        let rows = stmt.query_map(params![agent_id], |row| {
124            Ok(AgentContent {
125                agent_id: row.get(0)?,
126                content_type: row.get(1)?,
127                content: row.get(2)?,
128                updated_at: row.get(3)?,
129            })
130        })?;
131        let mut contents = Vec::new();
132        for row in rows {
133            contents.push(row?);
134        }
135        Ok(contents)
136    }
137
138    pub fn agent_content_get_all(
139        &self,
140        agent_id: &str,
141    ) -> Result<Vec<AgentContent>, StoreError> {
142        let local = self.agent_content_get_all_local(agent_id)?;
143        if !local.is_empty() {
144            return Ok(local);
145        }
146        // Local is empty. Fall back to the global record ONLY for a
147        // cross-channel agent (one absent from local SQLite). A locally-known
148        // agent with genuinely empty content must return empty — otherwise
149        // deleting its content would resurrect it from the global record.
150        // (reagent P1 on #1385.)
151        if self.agent_def_exists_local(agent_id)? {
152            return Ok(local);
153        }
154        if let Some(reg) = self.shared_def_registry() {
155            if let Ok(Some(rec)) = reg.get(agent_id) {
156                return Ok(rec
157                    .data
158                    .content
159                    .iter()
160                    .map(|c| AgentContent {
161                        agent_id: agent_id.to_string(),
162                        content_type: c.content_type.clone(),
163                        content: c.content.clone(),
164                        updated_at: rec.data.updated_at,
165                    })
166                    .collect());
167            }
168        }
169        Ok(local)
170    }
171
172    /// Delete a specific content blob. Returns true if a row was deleted.
173    pub fn agent_content_delete(
174        &self,
175        agent_id: &str,
176        content_type: &str,
177    ) -> Result<bool, StoreError> {
178        let rows = {
179            let conn = self.conn.lock().unwrap();
180            conn.execute(
181                "DELETE FROM db_agent_content WHERE agent_id=?1 AND content_type=?2",
182                params![agent_id, content_type],
183            )?
184        };
185        // conn dropped — re-mirror the definition so the global cross-channel
186        // record drops the just-deleted content key too. Symmetric with
187        // `agent_content_set`: without this a reset-to-default (e.g. ui:zoom)
188        // clears the local row but leaves the stale value in the shared
189        // def-registry, which a cross-channel/other-instance reopen would
190        // resurrect via the `agent_content_get` registry fallback. (P0.2b.)
191        self.registry_def_upsert(agent_id);
192        Ok(rows > 0)
193    }
194}